feat(mac): replace the NSMenu with a custom menu bar panel - #237
Merged
Conversation
Deleting ~/.cix/data does not stay a "missing database" for long: the very next start attempt recreates an empty cix.db (the server runs migrations before bootstrapAuth refuses), so needsFirstRun's file-existence check answers false from then on, and Start showed the raw refusal from cix-server.err instead of offering setup. The reliable signal for "no accounts" is the server's own refusal text, so recognise it: isBootstrapRefusal matches the two bootstrapAuth messages a user-less database produces, and toggleServer routes a matching died-on-start log to the same set-up-again offer as a missing database. Other startup failures still show the log tail — a port clash is not a reason to offer re-setup. Also prefill the wizard's email prompt with the address already in server.env, since a re-run almost always wants the same one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status menu becomes a popover-style panel following the menubar-panel
design spec: status as a coloured header with the port as the hero line,
technical data in a right-aligned mono table instead of disabled menu
rows, one weighted action button per state, square toggles with
consequence hints, and a footer that keeps Quit next to its "server keeps
running" reassurance.
Implementation is a thin Objective-C layer (panel_darwin.m) owning the
NSStatusItem, a borderless nonactivating NSPanel and a WKWebView that
renders one embedded HTML file (panel.html) — the entire look in one
reviewable file, dark mode via prefers-color-scheme for free. Go pushes a
panelState JSON after every poll; the panel posts {action} messages back.
All behaviour (busy gating, first-run routing, updates, network/autostart
toggles, password reset) is unchanged and stays in Go.
Design deviations, deliberate: a STARTING state exists (cold starts load
an embedding model for minutes) and renders with an indeterminate
progress sweep, as does INDEXING — the server reports a job count, not a
percentage, so nothing pretends to be one; the stopped state keeps a
small table (installed runtime + app version) and the toggles, because
both work without a running server.
fyne.io/systray is gone, along with the dot-PNG renderer and the NSMenu
row-width machinery it existed for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reported paper cuts in the new panel:
Start/Stop dismissed the panel like a menu item, hiding exactly the
feedback the click was owed. Server operations (start, stop, both
toggles) now keep the panel open; only actions that hand off to another
surface — dashboard, dialogs, quit — dismiss.
The wait itself looked different per operation: Start/Stop showed nothing
(the panel was gone), the toggles a static "Working…". Every operation
that claims the busy lock now renders the same loader — three pulsing
square cells in the design's blocky language — and beginBusy takes a
label naming the operation ("Stopping the server…", "Applying the
setting…", "Installing the update…"), because the flag says only that
something is slow and the label is what says what. The Start/Stop click
swaps the button for the loader optimistically, on the click itself
rather than a state-push round-trip later; a refused busy claim renders
the real state back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app used to open separate osascript windows for everything — info popups, confirmations, the setup wizard's email prompt, the password display — none of which looked anything like the panel. All of them now render as an in-panel area that takes over the content (panel.html #dialog): same tokens, same square controls, selectable mono block for credentials, Enter/Esc answer the dialog, and closing the panel counts as declining. Mechanics: a dialog request is pushed to the webview as JSON and the panel is fronted so it is actually seen; the calling goroutine blocks on a channel until the answer comes back over the existing action bridge. Dialogs serialise on a mutex. The osascript layer remains only as the fallback for the window before the webview exists (and for a translocated bundle, which never gets a panel). First-launch setup — the foreign-agent question, the wizard, the runtime download — moves from main() to after the panel is up (menu.startupFlow, under the busy claim), so its dialogs render in-panel too and the menu bar icon appears immediately instead of after a download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first in-panel dialog implementation hid the panel's sections and morphed the window into the question. What was asked for is the Docker-Desktop shape: the main window stays where it is, dimmed under a scrim, and the form floats over it as a modal card — visibly part of the same window. The card gets a 120ms rise-in, the scrim swallows clicks to the content behind (and a click on it declines, like Esc), and when the card is taller than the panel's own content the panel grows to hold it and shrinks back after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking Updates… dismissed the panel, ran the check in silence, and reopened the window with the verdict — a close-then-reopen blink with a dead gap in the middle. Now nothing except Open Dashboard and Quit dismisses the panel, and the check puts up a modal 'busy' card at once: title plus the pulsing-cells loader, no buttons, nothing to answer. The verdict dialog replaces the card in place. The same card covers the other silent network waits — the runtime download inside the wizard, the startup repair, and the reset-password path's on-demand download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-update for the .app was fully implemented and completely invisible: the panel showed the server's version but never the app's, and the up-to-date dialog folded both into one sentence that never said "app" — so it read as a server-only check, and the launcher's own updater looked like something nobody had written. The running table now names both components, `server` and `app`, and the dialog reports them on separate lines. A development build says outright that it does not replace itself, which is the actual reason a locally built app is never offered an update. Also covers the path with tests, because it had never been executed — no mac/v* release exists, so the app half of the updater has only ever seen an empty stream. stageUpdate is split out of updateLauncher (the swap ends the process, so a test cannot reach past it) and driven against a fake releases API serving a real signed DMG: the version comparison, the download, the checksum, the mount and the signature check. A tampered image must be discarded and leave nothing staged. swap.sh gets its own test with a real process to wait for and a stub `open` on PATH, covering both the replacement and its refusal to swap under a live launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extracted from checkForUpdates so both shapes are testable: a released build reports "cix app / vX.Y.Z" and drops the caveat, and a build from source keeps it. The caveat is the answer to "why is an update never offered", so it must appear on exactly one of the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Implements the menubar-panel design spec on top of the existing launcher functionality: the NSMenu is replaced by a popover-style panel (392pt, 12pt radius, 1.5pt border, full-bleed dividers, square controls, JetBrains Mono/SF Mono data).
RUNNING,:porthero, uptimeINDEXING, jobs · projects, sweep barSTARTING, sentence, sweep barSTOPPED, consequence sentenceHow
panel_darwin.m— NSStatusItem + borderless nonactivatingNSPanel+WKWebView; system window shadow around the HTML's rounded rectangle; dismissal on outside click / key-loss / Esc; JS-reported height drives the window frame.panel.html— the whole look, embedded viago:embed; light+dark fromprefers-color-scheme;cixRender(state)in,{action}messages out.panelstate_darwin.go— one JSON contract (buildPanelState), plus uptime (ps -o etime) and LAN-address helpers.menu_darwin.go— all behaviour unchanged (busy gating, first-run routing incl. fix(mac): route the server's no-admin refusal back to the setup wizard #236's bootstrap-refusal path, updates, toggles, reset password); only the rendering side swapped.fyne.io/systrayremoved; dot-PNG renderer and NSMenu width-cap machinery deleted with their tests.Follow-up commits (user feedback during live testing)
#dialog), same tokens and square controls, credentials in a selectable mono block, Enter/Esc answer, closing the panel counts as declining. osascript remains only as the pre-webview fallback. First-launch setup moves to after the panel is up (menu.startupFlow, under the busy claim), so the wizard renders in-panel too and the menu bar icon appears immediately.Deliberate deviations from the spec
:portas the hero and sweeps the progress cells:/statusreportsactive_indexing_jobs, not a percentage, so nothing pretends to be one.Testing
go vet/go test ./...green; panel HTML and all dialog kinds verified in-browser against all states, light and dark.Based on #236 (includes its commit).
🤖 Generated with Claude Code